An optional non-list key makes isList Maybe, not No - #6025
Conversation
e27c6d8 to
9fa048d
Compare
| public function narrowing(array $optStr): void | ||
| { | ||
| if (array_is_list($optStr)) { | ||
| assertType('list{}&list', $optStr); |
There was a problem hiding this comment.
Before this fix array{a?: string} had isList = No, so this branch was *NEVER* (unreachable). With isList now Maybe, the empty-array realisation survives the is list intersection, so the branch is reachable and narrows to the empty list.
| } else { | ||
| // array_is_list()'s false branch is not narrowed, so `a` stays optional | ||
| // here rather than being refined to array{a: string}. | ||
| assertType('array{a?: string}', $optStr); |
There was a problem hiding this comment.
The a? here (rather than array{a: string}) is pre-existing and unrelated to this fix: array_is_list()'s false branch is not refined. It stays a? for sealed shapes too — e.g. even array{0: string, a?: string} keeps a? in the false branch on current stable.
|
Update: I folded the The only remaining difference between the two PRs is the projection of an invalid sealed list-shape in |
|
This pull request has been marked as ready for review. |
702067f to
25d81d1
Compare
e3e6293 to
291f84b
Compare
|
This pull request has been marked as ready for review. |
91450e0 to
501c39d
Compare
8928a85 to
d9f6883
Compare
| // A sealed shape with an optional key past the gap at 1 (2? here): key 2 can | ||
| // never appear in a list, so makeList() drops it and projects to the prefix. | ||
| BleedingEdgeToggle::withBleedingEdge(true, function (): void { | ||
| $array = $this->buildShape([[0, new IntegerType(), false], [2, new StringType(), true]]); | ||
| $this->assertSame(TrinaryLogic::createMaybe()->describe(), $array->isList()->describe()); | ||
| $this->assertSame('array{int}', $array->makeList()->describe(VerbosityLevel::precise())); | ||
| }); | ||
|
|
||
| // Unsealed: extras may fill the gap, so key 2 is reachable and every key is kept. | ||
| BleedingEdgeToggle::withBleedingEdge(false, function (): void { | ||
| $array = $this->buildShape([[0, new IntegerType(), false], [2, new StringType(), true]]); | ||
| $this->assertSame(TrinaryLogic::createMaybe()->describe(), $array->isList()->describe()); | ||
| $this->assertSame('array{0: int, 2?: string}', $array->makeList()->describe(VerbosityLevel::precise())); | ||
| }); |
There was a problem hiding this comment.
can we use a data-provider to ease reading the test? atm its hard to see where the diff is between with/without bleeding edge.
same in the other test
There was a problem hiding this comment.
Done. Both tests are now data providers. testMakeListProjectsSealedShapeButKeepsUnsealedKeys has one row per sealedness: the input shape is the same, only the expected makeList() projection differs. testMergeWithRecomputesListnessOfSealedShape has one named row per {bleeding edge, shapes, expected isList}, so you can compare the with and without cases row by row. The test method applies the toggle itself, because data providers run before the container enables bleeding edge. I re-verified by manual mutation that the rows kill the mutants of the $naiveIsList->or(infer…) combinator in both mergeWith() and legacyMergeWith().
|
|
||
| public function testMergeWithTreatsNumericStringKeyAsIntWhenRecomputingListness(): void | ||
| { | ||
| BleedingEdgeToggle::withBleedingEdge(true, function (): void { |
There was a problem hiding this comment.
do we need a BleedingEdgeToggle::withBleedingEdge(false,...) variant of this test?
There was a problem hiding this comment.
Yes, added. inferIsListFromShape() has two call sites: mergeWith() (bleeding edge, sealed by the explicit-never unsealed marker) and legacyMergeWith() (unsealed === null). The test is now a data provider with both variants. The new withBleedingEdge(false) row goes through legacyMergeWith() and fails with Maybe instead of Yes without the toArrayKey() normalization, so it covers the second call site.
01f9c5a to
dc9a7e6
Compare
staabm
left a comment
There was a problem hiding this comment.
I like the new data-provider tests a lot more. thank you
A ConstantArrayType whose only list-incompatible keys are optional can
still be a list when those keys are absent (`array{a?: string}` admits
`[]`), so its isList must be Maybe, not No. Previously any string /
negative / gap key forced No regardless of optionality, which made
`array_is_list()` report "always false" on such shapes.
- ConstantArrayTypeBuilder: an optional list-incompatible key degrades
isList Yes to Maybe (No stays No) via markNonListKey().
- ArrayType::isSuperTypeOf(): a possibly-empty constant array always
admits `[]`, a subtype of every array type, so the relationship is at
worst Maybe — never a definite No. This lets the `($value is list)`
conditional of array_is_list() resolve to bool for possibly-empty
shapes instead of false.
- ConstantArrayType::makeList(): now that gap/string optional keys yield
isList Maybe, intersecting a sealed such shape with list keeps only the
contiguous 0..m prefix (the keys that can actually appear in a list)
instead of collapsing to *NEVER*.
Closes phpstan/phpstan#14938
…ergeWith with a numeric-string key
dc9a7e6 to
badee13
Compare
|
@SanderMuller here another PR you could review. thank you |
|
Reviewed this one — I checked it out and ran it rather than reading only. Short version: the reasoning holds up, the fix does what it claims, and I could not make it report anything wrong. The premise is right. Behaviour,
The last three are the ones I was most interested in — a required non-list key, a shape narrowed to non-empty, and a genuine list all keep their old answer, so the new
Gates, all on the PR head: full suite green (21302 tests / 96876 assertions, and again green run sequentially rather than through paratest), On the four red jobs — I looked at each, none of them is this PR as far as I can tell:
Two things I would ask, neither blocking:
Last thing, for whoever merges: #6026 is still open and fixes the same issue, and this PR now contains its merge-side fix, so the two need deciding together rather than separately. The remaining difference is the |
A
ConstantArrayTypewhose only list-incompatible keys are optional can still be a list when those keys are absent —array{a?: string}admits[], andarray_is_list([]) === true. So itsisListmust be Maybe, not No. Previously any string / negative / gap key forcedisList = Noregardless of optionality, soarray_is_list()was reported as always false on such shapes.This is the dual of #12725 (there, shapes that also admit non-list values wrongly report
isListyes; here, shapes that also admit list values wrongly report no).Parts:
ConstantArrayTypeBuilder— an optional list-incompatible key degradesisListYes→Maybe (No stays No) via a newmarkNonListKey(), replacing four unconditionalcreateNo()sites.ArrayType::isSuperTypeOf()— a possibly-empty constant array always admits[], which is a subtype of every array type, so the relationship is at worst Maybe, never a definite No. This lets the($value is list ? true : false)conditional ofarray_is_list()resolve toboolfor possibly-empty shapes (e.g.array{a?: string}) instead offalse.ConstantArrayType::makeList()— now that gap/string optional keys yieldisListMaybe, intersecting a sealed such shape withlistkeeps only the contiguous0..mprefix (the keys that can actually appear in a list) rather than collapsing to*NEVER*. Unsealed extras may fill the gaps, so there every key is kept.ConstantArrayType::mergeWith()/legacyMergeWith()— merging widens keys present in only one side into optional keys, so the result can admit list realizations neither input did (array{a: 1}|array{b: 2}→array{a?: 1, b?: 2}admits[]). The list-ness of a sealed merged shape is now recomputed from the shape viainferIsListFromShape()instead of the too-strict$this->isList->and($otherArray->isList); two pure lists still merge into a list. This is the same merge-side fix as DegradeisListtomaybefor optional non-list keys in array shapes and shape merges #6026, folded in here — thanks to the parallel analysis there. (The one deliberate difference from DegradeisListtomaybefor optional non-list keys in array shapes and shape merges #6026 ismakeList()above: this PR projects invalid sealed list-shapes to their valid form, e.g.list{0: string, 1: int, 2?: string, 4?: string}→list{0: string, 1: int, 2?: string}, rather than keeping the unreachable key; see the tradeoff note on DegradeisListtomaybefor optional non-list keys in array shapes and shape merges #6026.)The existing
array-shape-list-optional.phpexpectations change accordingly: an invalid sealed list-shape likelist{0: string, 1: int, 2?: string, 4?: string}now resolves to its valid projectionarray{0: string, 1: int, 2?: string}.Closes phpstan/phpstan#14938